﻿/*
    PerstSyncProvider
    Copyright (C) 2011 Mikolas Bingemer, MediFox GmbH

    This library is free software; you can redistribute it and/or
    modify it under the terms of the GNU Library General Public
    License as published by the Free Software Foundation; either
    version 2.1 of the License, or (at your option) any later version.

    This library is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    Library General Public License for more details.

    You should have received a copy of the GNU Library General Public
    License along with this library; if not, write to the Free Software
    Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
    MA 02110-1301  USA.
 */

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using Microsoft.Synchronization.ClientServices;
using Perst;
using Perst.Impl;

namespace MediFox.Synchronization.ClientServices.PerstSyncProvider
{
    /// <summary>
    /// An implementation of Microsoft.Synchronization.ClientServices.OfflineSyncProvider using Perst4.NET
    /// </summary>
    public class PerstOfflineContext : OfflineSyncProvider, IDisposable
    {
        private PerstOfflineSchema _schema;
        private StorageHandler _storageHandler;
        private CacheController _cacheController;
        private ContextMetadata _metadata;
        private volatile bool _isActive;
        private bool _isDisposed;

        private readonly MethodInfo _addTypeMethod = typeof(CacheControllerBehavior).GetMethod("AddType");

        private AutoResetEvent _waitHandle;

        internal class Lock : IDisposable
        {
            private AutoResetEvent _waitHandle;

            public Lock(AutoResetEvent waitHandle)
            {
                _waitHandle = waitHandle;
                _waitHandle.WaitOne();
            }

            public void Dispose()
            {
                _waitHandle.Set();
                _waitHandle = null;
                GC.SuppressFinalize(this);
            }
        }

        #region properties

        public PerstOfflineSchema Schema
        {
            get
            {
                ThrowIfDisposed();
                return _schema;
            }
        }

        [Obsolete("not used")]
        internal StorageHandler StorageHandler
        {
            get
            {
                return _storageHandler;
            }
        }

        public CacheController CacheController
        {
            get
            {
                ThrowIfDisposed();
                return _cacheController;
            }
        }

        public bool IsActive
        {
            get
            {
                ThrowIfDisposed();
                return _isActive;
            }
        }

        public bool IsDisposed
        {
            get
            {
                return _isDisposed;
            }
        }

        public bool IsDirty
        {
            get
            {
                ThrowIfDisposed();

                if (_isActive)
                {
                    throw new SyncSessionActiveException("Cannot access this property while sync session is active");
                }

                return _storageHandler.HasDirty();
            }
        }

        public long DatabaseSize
        {
            get
            {
                ThrowIfDisposed();
                return _storageHandler.Database.Storage.DatabaseSize;
            }
        }

        public long UsedSize
        {
            get
            {
                ThrowIfDisposed();
                return _storageHandler.Database.Storage.UsedSize;
            }
        }

        public string ScopeName
        {
            get
            {
                ThrowIfDisposed();
                return _metadata.ScopeName;
            }
        }

        public string ScopeUri
        {
            get
            {
                ThrowIfDisposed();
                return _metadata.ScopeUri;
            }
        }

        public DateTime? LastSync
        {
            get
            {
                ThrowIfDisposed();
                DateTime? lastSyncTime;
                if (_metadata.LastSync > 0)
                {
                    lastSyncTime = new DateTime(_metadata.LastSync);
                }
                else
                {
                    lastSyncTime = new DateTime?();
                }
                return lastSyncTime;
            }
        }

        #endregion

        #region ctor

        public PerstOfflineContext(PerstOfflineSchema schema, string scopeName, string cachePath, Uri syncUri)
            : this(schema, scopeName, cachePath, syncUri, StorageImpl.DEFAULT_PAGE_POOL_SIZE, null)
        {
        }

        public PerstOfflineContext(PerstOfflineSchema schema, string scopeName, string cachePath, Uri syncUri, long pagePoolSize)
            : this(schema, scopeName, cachePath, syncUri, pagePoolSize, null)
        {
        }

        public PerstOfflineContext(PerstOfflineSchema schema, string scopeName, string cachePath, Uri syncUri, string cipherKey)
            : this(schema, scopeName, cachePath, syncUri, StorageImpl.DEFAULT_PAGE_POOL_SIZE, cipherKey)
        {
        }

        public PerstOfflineContext(PerstOfflineSchema schema, string scopeName, string cachePath, Uri scopeUri, long pagePoolSize, string cipherKey)
        {
            Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            if (schema == null)
            {
                throw new ArgumentNullException("schema");
            }

            if (string.IsNullOrEmpty(scopeName))
            {
                throw new ArgumentNullException("scopeName");
            }

            if (string.IsNullOrEmpty(cachePath))
            {
                throw new ArgumentNullException("cachePath");
            }

            if (scopeUri == null)
            {
                throw new ArgumentNullException("scopeUri");
            }

            if (pagePoolSize < 65536)
            {
                throw new ArgumentException("PagePoolSize must be at least 64kB", "pagePoolSize");
            }

            // Create storage handler
            _storageHandler = CreateStorageHandler(schema, scopeName, cachePath, scopeUri, pagePoolSize, cipherKey);

            // Create SyncFx CacheController
            _cacheController = CreateCacheController(schema, scopeUri, scopeName);

            _schema = schema;

            // initialize wait handle
            _waitHandle = new AutoResetEvent(true);
        }

        #endregion

        #region private methods

        private StorageHandler CreateStorageHandler(PerstOfflineSchema schema, string scopeName, string cachePath, Uri scopeUri, long pagePoolSize, string cipherKey)
        {
            StorageHandler storageHandler = new StorageHandler(schema, cachePath, pagePoolSize, cipherKey);

            // check for existing OfflineContextMetadata
            ContextMetadata metadata = storageHandler.Database.GetRecords<ContextMetadata>(false).FirstOrDefault();
            if (metadata == null)
            {
                metadata = CreateMetadata(storageHandler, scopeName, scopeUri);
            }
            else
            {
                // validate metadata
                if (metadata.ScopeName != scopeName)
                {
                    throw new ArgumentException("Specified scope name does not match scope name previously used for the specified cache path", "scopeName");
                }

                if (metadata.ScopeUri != scopeUri.AbsoluteUri)
                {
                    throw new ArgumentException("Specified uri does not match uri previously used for the specified cache path", "scopeUri");
                }
            }

            // keep local reference
            _metadata = metadata;

            return storageHandler;
        }

        private ContextMetadata CreateMetadata(StorageHandler storageHandler, string scopeName, Uri scopeUri)
        {
            // create new OfflineContextMetadata
            ContextMetadata metadata = new ContextMetadata(scopeName, scopeUri);

            // store OfflineContextMetadata
            storageHandler.Database.BeginTransaction();
            try
            {
                if (!storageHandler.Database.AddRecord(metadata))
                {
                    throw new DatabaseException("Failed to add Metadata");
                }
                storageHandler.Database.CommitTransaction();
            }
            catch
            {
                storageHandler.Database.RollbackTransaction();
                throw;
            }
            return metadata;
        }

        private CacheController CreateCacheController(PerstOfflineSchema schema, Uri scopeUri, string scopeName)
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());

            CacheController cacheController = new CacheController(scopeUri, scopeName, this);

            CacheControllerBehavior controllerBehavior = cacheController.ControllerBehavior;
            foreach (Type entityType in schema.Collections)
            {
                MethodInfo genericAddTypeMethod = _addTypeMethod.MakeGenericMethod(new Type[] { entityType });
                genericAddTypeMethod.Invoke(controllerBehavior, new object[0]);
            }

            return cacheController;
        }

        private void ThrowIfDisposed()
        {
            if (_isDisposed)
            {
                System.Diagnostics.Debug.Assert(false);
                throw new ObjectDisposedException("Cannot access a disposed PerstOfflineContext");
            }
        }

        #endregion

        #region public methods

        public void AddItem<T>(T entity) where T : PerstOfflineEntity
        {
            Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (entity == null)
            {
                throw new ArgumentNullException("entity");
            }

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot add items while sync session is active");
            }

            //if (_storageHandler.GetAnchor().Length == 0)
            //{
            //    throw new InvalidOperationException("Cannot add items before an initial sync has occurred");
            //}

            using (new Lock(_waitHandle))
            {
                entity.IsDirty = true;
                _storageHandler.AddEntity(entity);
            }
        }

        public void AddRange<T>(IEnumerable<T> entities) where T : PerstOfflineEntity
        {
            Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (entities == null)
            {
                throw new ArgumentNullException("entities");
            }

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot add items while sync session is active");
            }

            using (new Lock(_waitHandle))
            {
                foreach (var entity in entities)
                {
                    entity.IsDirty = true;
                }
                _storageHandler.AddRange(entities.Cast<PerstOfflineEntity>());
            }
        }

        public void DeleteItem<T>(T entity) where T : PerstOfflineEntity
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (entity == null)
            {
                throw new ArgumentNullException("entity");
            }

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot delete items while sync session is active");
            }

            //if (_storageHandler.GetAnchor().Length == 0)
            //{
            //    throw new InvalidOperationException("Cannot delete items before an initial sync has occurred");
            //}

            using (new Lock(_waitHandle))
            {
                _storageHandler.TombstoneEntity(entity);
            }
        }

        public void DropItem<T>(T entity) where T : PerstOfflineEntity
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (entity == null)
            {
                throw new ArgumentNullException("entity");
            }

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot drop items while sync session is active");
            }

            using (new Lock(_waitHandle))
            {
                _storageHandler.DropEntity(entity);
            }
        }

        public void DropItems<T>(IEnumerable<T> entities) where T : PerstOfflineEntity
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (entities == null)
            {
                throw new ArgumentNullException("entities");
            }

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot drop items while sync session is active");
            }

            using (new Lock(_waitHandle))
            {
                _storageHandler.DropEntities(entities.Cast<PerstOfflineEntity>());
            }
        }

        public void DropItems(IEnumerable<PerstOfflineEntity> entities)
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (entities == null)
            {
                throw new ArgumentNullException("entities");
            }

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot drop items while sync session is active");
            }

            using (new Lock(_waitHandle))
            {
                _storageHandler.DropEntities(entities);
            }
        }

        public IEnumerable<T> GetCollection<T>() where T : PerstOfflineEntity
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            return _storageHandler.Database.GetTable<T>();
        }

        public void DropCollection<T>() where T : PerstOfflineEntity
        {
            ThrowIfDisposed();

            _storageHandler.DropCollection<T>();
        }

        public IEnumerable GetRecords(Type entityType, bool forUpdate = false)
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (!typeof(PerstOfflineEntity).IsAssignableFrom(entityType))
            {
                throw new ArgumentOutOfRangeException("Type must be assignable from PerstOfflineEntity");
            }

            return _storageHandler.Database.GetRecords(entityType, forUpdate);
        }

        public IEnumerable<T> GetRecords<T>(bool forUpdate = false) where T : PerstOfflineEntity
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            return _storageHandler.Database.GetRecords<T>(forUpdate);
        }

        public int CountRecords(Type entityType)
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (!typeof(PerstOfflineEntity).IsAssignableFrom(entityType))
            {
                throw new ArgumentOutOfRangeException("Type must be assignable from PerstOfflineEntity");
            }

            return _storageHandler.Database.CountRecords(entityType);
        }

        public int CountRecords<T>() where T : PerstOfflineEntity
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            return _storageHandler.Database.CountRecords<T>();
        }

        public IEnumerable<T> Select<T>(string predicate, bool forUpdate = false)
            where T : PerstOfflineEntity
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod(), typeof(T).Name);

            ThrowIfDisposed();

            return _storageHandler.Database.Select<T>(predicate, forUpdate);
        }

        public IEnumerable<T> Select<T>(System.Linq.Expressions.Expression<Func<T, bool>> expression, bool forUpdate = false)
            where T : PerstOfflineEntity
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod(), typeof(T).Name);

            ThrowIfDisposed();

            return _storageHandler.Database.Select<T>(expression, forUpdate);
        }

        public Query CreateQuery<T>(bool forUpdate = false)
            where T : PerstOfflineEntity
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod(), typeof(T).Name);

            ThrowIfDisposed();

            return _storageHandler.Database.CreateQuery(typeof(T), forUpdate);
        }

        public Query Prepare<T>(string predicate, bool forUpdate = false)
            where T : PerstOfflineEntity
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod(), typeof(T).Name);

            ThrowIfDisposed();

            return _storageHandler.Database.Prepare(typeof(T), predicate, forUpdate);
        }

        public GenericIndex GetIndex<T>(string key)
            where T : PerstOfflineEntity
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod(), typeof(T).Name);

            ThrowIfDisposed();

            return _storageHandler.Database.GetIndex(typeof(T), key);
        }

        public void ClearCache()
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot clear cache while sync session is active");
            }

            using (new Lock(_waitHandle))
            {
                // keep track of metadata
                string scopeName = _metadata.ScopeName;
                Uri scopeUri = new Uri(_metadata.ScopeUri);

                // wipe storage
                _storageHandler.Wipe();

                // create fresh metadata
                _metadata = CreateMetadata(_storageHandler, scopeName, scopeUri);
            }
        }

        public void Flush()
        {
            ThrowIfDisposed();

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot flush context while sync session is active");
            }

            using (new Lock(_waitHandle))
            {
                _storageHandler.Database.Storage.Commit();
            }
        }

        public void Close()
        {
            //ThrowIfDisposed();

            //if (_isActive)
            //{
            //    throw new SyncSessionActiveException("Cannot close context while sync session is active");
            //}

            if (CacheController.IsBusy)
                CacheController.CancelAsync();

            Dispose();
        }

        public void SwitchUri(Uri scopeUri)
        {
            if (scopeUri == null)
            {
                throw new ArgumentNullException("scopeUri");
            }

            ThrowIfDisposed();

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot switch uri while sync session is active");
            }

            // TODO: to check or not to check for dirty records?
            //if (_storageHandler.HasDirty())
            //{
            //    throw new InvalidOperationException("Cannot switch uri of a dirty context");
            //}

            using (new Lock(_waitHandle))
            {
                // Create new SyncFx CacheController
                _cacheController = CreateCacheController(_schema, scopeUri, _metadata.ScopeName);

                // update metadata
                _metadata.ScopeUri = scopeUri.AbsoluteUri;
                _metadata.Anchor = new byte[0];
                _metadata.LastSync = 0;
                _metadata.Modify();
            }
        }

        public void ShrinkCache()
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot shrink cache while sync session is active");
            }

            using (new Lock(_waitHandle))
            {
                _storageHandler.Shrink();
            }

        }

        public bool BackupCache(string backupFileName)
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot backup cache while sync session is active");
            }

            using (new Lock(_waitHandle))
            {
                return _storageHandler.Backup(backupFileName);
            }
        }

        public bool RestoreCache(string backupFileName)
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot restore cache while sync session is active");
            }

            using (new Lock(_waitHandle))
            {
                return _storageHandler.Restore(backupFileName);
            }
        }

        public void ExportCache(string xmlFileName)
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (_isActive)
            {
                throw new SyncSessionActiveException("Cannot export cache while sync session is active");
            }

            using (new Lock(_waitHandle))
            {
                _storageHandler.ExportXml(xmlFileName);
            }
        }

        #region OfflineSyncProvider

        // see http://msdn.microsoft.com/en-us/library/gg298997%28v=SQL.110%29.aspx

        /// <summary>
        /// This method is invoked when the sync controller is about to start a sync session. An offline sync provider might perform operation such as loading the cache into memory if the cache was not already loaded.
        /// </summary>
        public override void BeginSession()
        {
            Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (_isActive)
            {
                throw new InvalidOperationException("Sync session is allready active");
            }

            // wait for and lock wait event handle
            _waitHandle.WaitOne();

            _isActive = true;
        }

        /// <summary>
        /// An offline sync provider should return the last server blob that the provider received from the service as part of the last sync operation.
        /// </summary>
        /// <returns></returns>
        public override byte[] GetServerBlob()
        {
            Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (!_isActive)
            {
                throw new InvalidOperationException("Sync session is not active");
            }

            return _metadata.Anchor;
        }

        /// <summary>
        /// Returns the set of incremental changes to send to the service. An offline sync provider typically extracts changes from the underlying store and returns those changes in a ChangeSet object.
        /// </summary>
        /// <param name="state">A unique identifier assigned by the runtime for the change set that is returned by the GetChangeSet call. This is latter passed in the OnChangeSetUploaded call to help the OfflineSyncProvider correlate the upload request with the response from the service.</param>
        public override ChangeSet GetChangeSet(Guid state)
        {
            Logger.Log(System.Reflection.MethodBase.GetCurrentMethod(), state.ToString());
            ThrowIfDisposed();

            if (!_isActive)
            {
                throw new InvalidOperationException("Sync session is not active");
            }

            // prepare new changeset
            ChangeSet changeSet = new ChangeSet();

            // retrieve anchor
            changeSet.ServerBlob = _metadata.Anchor;

            // only send changes after initial sync has occured
            if (changeSet.ServerBlob.Length > 0)
            {
                // retrieve changes
                IEnumerable<PerstOfflineEntity> changes = _storageHandler.GetChanges(state);

                // assign entities for sync
                changeSet.Data = changes.Cast<IOfflineEntity>().ToList();
            }

            // TODO: implement batching
            changeSet.IsLastBatch = true;

            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod(), string.Format("ChangeSet {0} contains {1} entities", state, changeSet.Data.Count));

            return changeSet;
        }

        /// <summary>
        /// This method is called when a change set has been successfully uploaded.
        /// </summary>
        /// <param name="state">The unique identifier passed in to the GetChangeSet call.</param>
        /// <param name="response">A ChangeSetResponse object that contains an updated server blob and conflicts or errors that occurred at the server.</param>
        public override void OnChangeSetUploaded(Guid state, ChangeSetResponse response)
        {
            Logger.Log(System.Reflection.MethodBase.GetCurrentMethod(), state.ToString());
            ThrowIfDisposed();

            if (!_isActive)
            {
                throw new InvalidOperationException("Sync session is not active");
            }

            if (response.Error == null)
            {
                _storageHandler.SaveChangesetResponse(state, response);
            }
            else
            {
                Logger.Log(System.Reflection.MethodBase.GetCurrentMethod(), string.Format("{0} - {1}: {2}", state, response.Error.GetType().Name, response.Error.Message));

                _storageHandler.ForgetChangeset(state);

                // TODO: SyncFX CTP fails to properly marshall this to the consumer of the sync
                throw new SyncException("Failed to upload changeset", response.Error);
            }

        }

        /// <summary>
        /// This method is called to save changes retrieved from the sync service. A CacheController invokes the SaveChangeSet method of OfflineSyncProvider with changes receives from the service. The offline sync provider must write these changes to the underlying store to stay in sync with the service.
        /// </summary>
        /// <param name="changeSet">The set of changes from the service to save. It also contains an updated server blob.</param>
        public override void SaveChangeSet(ChangeSet changeSet)
        {
            Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (changeSet == null)
            {
                throw new ArgumentNullException("changeSet");
            }

            if (!_isActive)
            {
                throw new InvalidOperationException("Sync session is not active");
            }

            if (changeSet.Data.Count != 0)
            {
                // cast downloaded entities
                IEnumerable<PerstOfflineEntity> downloadedEntities = changeSet.Data.Cast<PerstOfflineEntity>();

                // TODO: implement batching
                //changeSet.IsLastBatch

                // store downloaded entities
                _storageHandler.SaveChangeset(changeSet.ServerBlob, downloadedEntities);
            }
        }

        /// <summary>
        /// This method is invoked by the CacheController Class when it is about to end a sync session.
        /// </summary>
        public override void EndSession()
        {
            Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            ThrowIfDisposed();

            if (!_isActive)
            {
                throw new InvalidOperationException("Sync session is not active");
            }
            _isActive = false;

            // update last sync metadata
            _metadata.LastSync = DateTime.Now.Ticks;
            _metadata.Modify();

            // release wait event handle
            _waitHandle.Set();
        }

        #endregion

        #region IDisposable

        public void Dispose()
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool disposing)
        {
            //Logger.Log(System.Reflection.MethodBase.GetCurrentMethod());
            if (!_isDisposed)
            {
                if (disposing)
                {
                    _metadata = null;
                    if (_storageHandler != null)
                    {
                        _storageHandler.Dispose();
                        _storageHandler = null;
                    }
                    if (_waitHandle != null)
                    {
                        _waitHandle.Close();
                        _waitHandle = null;
                    }
                }
                _isDisposed = true;
            }
        }

        #endregion

        #endregion

    }
}
